原文链接:https://berk.es/2022/09/06/frameworks-harm-maintenance/在本文中,我们来探讨一下使用框架构建软件,对软件的可维护性有哪些危害。我认为:
框架是什么?
首先,我们来弄清楚框架的准确含义。框架不仅仅是使用第三方代码,也不仅仅是一种方法或架构:软件框架(software framework),通常指的是为了实现某个业界标准或完成特定基本任务的软件组件规范,也指为了实现某个软件组件规范时,提供规范所要求之基础功能的软件产品。控制反转:框架与库或标准用户应用程序不同,整个程序的控制流不是由调用者决定的,而是由框架决定的。而这通常是通过模板来实现的。
可扩展性:用户可以按照重载的方式扩展框架,即编写用户专用的代码来提供特定的功能。
不可修改的框架代码:一般来说,框架代码不应该被修改,但可以接受用户的扩展。换句话说,用户可以扩展框架,但不能修改其代码。
根据定义,框架的主要功能是提供功能、行为、流程和默认值,而且所有这些都是框架内置的,其中一些是不可更改或指定的。框架允许用户添加代码,但不能更改其代码。所有的软件框架都可能引入维护的问题,但我个人使用框架的经验仅限于Web服务(API、后端、全栈)、命令行和GUI。2022年,越来越多的软件朝着Web发展,因此,本文讨论的例子也仅限于Web框架。人们使用框架的目的是,以更标准化、更快、更容易、更安全、更好的可扩展性、更一致或更有趣的方式开发软件。然而很讽刺的是,根据维基百科的介绍,使用框架不会提供任何好处,相反只有种种弊端。标准化背后的思想是,迫使开发人员按照事先定义好的方式编写代码。使用框架不仅可以统一代码的组织方式,而且API和逻辑也更容易辨识。然而,我发现结果却事与愿违。2021年开发运维现状报告(参考链接:https://puppet.com/blog/2021-state-of-devops-report/)表明,使用框架之类的技术根本无法保证项目成功。而强迫开发人员使用框架只能导致情况进一步恶化。低效公司的通病往往表现在:由一个团队定义标准、流程、实践、框架或架构,而其他团队则必须遵守。其实,这并非没有道理:如果强制公司中的每个人都使用 Django,而无论项目的实际情况如何,那么最终必然有很多项目会因为选择Django而处处碰壁。尽管如此,框架确实能够为某个项目或团队提供好处。但是,标准化(和统一)基本没有任何好处,甚至弊大于利。项目的开发速度、趣味性以及难易度,在很大程度上取决于项目所处的阶段。利用框架生成模型的代码,可以节省编写初始代码的时间。这一点我也同意。但是,对于一个开发了十几年的中大型团队来说,节省的这点时间(半个小时?)是微不足道的。尤其是,经过了这么长的时间,框架可能生成了数百个这样的模型,而其余几万个小时都花在了修改和维护现有代码上。下面,我会详细说明从项目的长期发展来看,这种短暂的“开发速度提升”换来的却是对可维护性的损害。此外,安全性和性能非常依赖于大环境。框架会向项目添加大量代码。运气好的话,这些代码无伤大雅;但倘若运气不佳,则可能引入大量的潜在攻击和大量的开销。我将在下文中展示其实不使用框架更加容易确保安全性并提高性能。“有损于维护性”指什么?
软件顺利启动,并投入使用,接下来我们只需要正常维护。维护通常分为以下几类:纠正式的软件维护:修复bug;
预防式的软件维护:防止错误,稳步改进;
完美式的软件维护:修饰与润色;
适应式的软件维护:持续开发。
不过,在本文中,我打算将软件投入使用后的所有变更都视为维护。在维护期间内,任何阻碍维护工作持续开展的因素,都应被视为危害。因此,如果使用框架会导致新功能的发布速度减慢,则视为危害。此外,如果在软件开发的早期,使用框架有助于快速发布功能,但相应的代价是导致后期新功能的发布速度减慢,则视为有损于维护性。第三种危害是,框架的使用导致我们需要付出额外的努力,但这部分工作并不能为客户提供价值,比如框架升级、弃用、教育和信息摄入(例如学习新功能)等。这些工作需要付出昂贵的代价,而且往往是稀缺资源,比如你需要花费大量时间升级技术栈,原本这些时间应花在提供用户或市场想要的新功能。最后一种危害是,将来框架有可能不再适合项目。如果框架朝着不同的方向发展,或者使用了框架的软件朝着不同的方向发展,那么二者就不再适配。框架与个人或团队有着不同的目标
虽然你寄予了框架巨大的希望,但框架并没有对你做出任何承诺。框架可以按照创始人的喜好,朝着任何方向发展。而你只能像一只忠实的小狗一样默默跟随。我敢肯定,大多数框架的创始人对用户没有任何敌意,他们发自真心关心用户,而DHH肯定也希望用户在使用Rails时感受到快乐。但是,这些创始人更关心的是有多少用户愿意使用框架,并一路相随,而不是你能否在接下来的十五、二十年内继续创造价值。许多Web框架,比如Django、Rails、Spring、Gatsby 和 Symfony等的营销词中都提到了维护以及可维护性。Symfony:加快创建和维护PHP Web应用程序的速度。摆脱重复的编程任务,享受控制代码的力量。使用最佳实践确保应用程序的稳定性、可维护性和可升级性。关于框架如何提供长期的支持,Rails 的官方立场是:当某个版本系列不再受支持时,修复错误和安全问题的责任由您自行承担。我们会提供修补程序的向后移植并发布到git,但是不会发布新版本。如果你无力维护自己的版本,则应升级到受支持的版本。(参考链接:https://rubyonrails.org/maintenance)他们的立场很明确:框架不会长期提供支持。为了让项目使用最新版本的Rails,你需要更新或移植框架,但这些工作都需要资源。再者,即便眼下框架与你的目标完全一致,但将来呢?尤其是对于刚刚启动的项目来说,谁又能预知未来呢?你的产品会坚持Web应用的路线?你确定将来只发布Windows桌面版的应用程序?你确定在接下来的几年中关系数据库是最佳存储解决方案?你确定你需要可扩展性?十年之后JavaScript PWA还会存在吗?然而,在选择框架构建产品时,你就与它深度绑定了。永远绑定了。在项目之初,在拥有的信息量最少的那一刻,你却做出了最关键的决定。框架设计中的权衡会危及项目的可维护性
与其他软件一样,框架的创建者必须做出权衡。例如,从流行框架的网站宣传中就可以看出,所有的流行框架都格外注重开发速度和可扩展性。然而,这两个特征与可维护性没有任何关系,相反在有些情况下还会损害可维护性。开发速度的提升部分来自样板代码的生成,但更多时候来自继承。框架生成代码就意味着创建新代码,但不负责维护这些代码。例如react-boilerplate 或 create-react-app等框架就会生成大量的样板代码,它们只是代码生成器。但代码必须维护,否则就会降级,并引发各种问题,比如大量重复、不一致、不兼容等,也就是我们常说的“代码腐烂”。框架可以通过其他手段解决代码腐烂的问题,比如将所有代码都放入超类(或可重用函数)中,这样就能在一个合理的地方统一提供样板代码。作为用户(即使用框架的开发人员),你可以继承类,或者采用mixin的方式使用其他类、模块或函数的代码。例如,在Rails中,你只需要继承“一个模型”,就可以让对象公开大量方法。举个例子,假设Post有三个数据库字段:class Post < ActiveRecord::Base; end
那么,你至少可以获得 767 个公共类方法和 487 个公共实例方法,也就是说,你可以通过子类化继承1200 多个方法!由于Post类提供了这么多方法,所以你就必须维护它们。毕竟,你的类为用户提供了这些方法。这些方法存在于你的类中、你的实例中。它们深埋于框架的代码中,这就成了你的责任,由你来维护它们。这就是框架的本质,你无法改变,也无法控制。框架甚至可以决定在某个时刻弃用或修改某个方法。由于使用了框架,所以我们提供了大量的公共接口,却没有能力控制它。我们的一切都将受到牵制,寄希望于框架的创建者是个好心人,能提供更新,并保证框架的向后兼容性和可用性。虽然大多数框架的创建者都很友好,但谁也无法保证这些API永远稳定。还有Drupal之类的框架提供的升级如此庞大,导致用户不得不完全重写项目,而且每隔几年就要经历一次这样的升级!虽然有些框架很友好,会努力保持向后兼容,而且每次升级都是很小的一步,但更新还是避免不了。而我们只能俯首听命,必要时修改现有代码。虽然许多框架不像 Rails 那样极端,公共接口包含 1200 多个方法。但所有框架都为用户提供了 API、函数和类,毕竟这正是框架存在的意义。我们使用这些代码,并随着时间的推移,将我们的代码更加紧密地耦合到框架中。直到我们的代码完全依赖于框架。所以人们常说,在框架内开发软件,而不是利用框架开发软件,因为你确实是在框架中构建项目。此外,框架所能提供的性能与扩展水平是相较于其他类似的框架而言的。如果我们能选择底层架构,并进行优化,那么就能利用更少的代码,编写更高效、更具扩展性的软件。而另一方面,各种框架却因导致项目出现性能问题,而频繁地出现在各大新闻头条中。例如,推特的“Fail-Whale”(失败鲸)事件就是因为Rails糟糕的性能引发的,后来推特宣布用Java重写了Rails代码库。此次事件证明,大多数框架都会显著增加性能开销。扩展和性能问题的常见解决方案是,选择适合的架构,优化底层代码,并减少总代码量,这就意味着我们必须能够在发现性能问题时自由修改代码。只有掌握足够的信息,我们才能做出正确的选择和优化。而框架会损害可扩展性,因为我们很难从一个框架迁移到更适合的其他框架或架构,或者建立更合适的设置。在遇到“Fail-Whale”之类的问题时,我们都希望优化有问题的代码,而不是用Java重写所有代码。框架的构建初衷就是为了控制你的项目
使用框架开发软件时,项目必然会与框架深度绑定。每次我们在Rails中编写:belongs_to(:author),或者在Django中编写:models.ForeignKey("Band"),就会导致我们的项目与框架的绑定更加紧密。如果只是很小的一部分代码绑定到框架,那么还能保证一定的可维护性。然而,当这种绑定的覆盖范围很大,界限模糊或完全消失时,就很难维护了。当我们的领域和业务逻辑与框架代码混在一起;当高级业务概念与底层的架构机制混在一起;当业务逻辑混入底层架构,我们必须阅读控制器、视图、模型、工厂、服务、配置文件、库、框架代码,才能搞明白为什么案例A中创建了User,而案例B不需要,那么可维护性就无从谈起了。框架抽象出了许多技术细节,它们会提供一个ORM来抽象数据库的处理,有时开发人员甚至根本不需要知道自己正在使用数据库。他们只需调用model.save或User.find_by(email: "example.com") ,就能保存或获取数据,而根本不知道这些数据实际上保存在PostgreSQL、sqlite还是MongoDB中。虽然我们不会被绑定到特定的数据库,但会绑定到ORM和框架。你可以自由使用任何数据库,但代价是无法再使用另一个ORM和框架。HTTP、存储(如数据库)、事件总线、日志记录、消息传递等底层的机制,所有这些都是细节,它们与你的业务逻辑和领域无关。会计应用的架构应该叫做“会计”,而不是 Spring & Hibernate。@unclebobmartin然而,这些框架还鼓励开发人员将逻辑与框架代码混合在一起。他们提供了各种API、类和函数,供我们在业务逻辑中使用。因此,我们的代码不仅会与框架紧密耦合,而且还会将业务逻辑和样板代码彻底混在一起。更糟糕的是,他们经常鼓励我们通过这些“细节”来传播业务逻辑。在MVC模型中,M是存储,V是模板,而C是HTTP层,却没有提供一个统一的、合乎逻辑的地方来保存逻辑和领域代码。框架鼓励我们将这些代码放在最近的地方,而不是最方便维护的地方。def create
if User.exists?(email: params[:email])
render :new, status: :already_exists
elsif user.save
flash[:success] = flash_message_for(@user, :successfully_created)
redirect_to edit_admin_user_path(@user)
else
render :new, status: :unprocessable_entity
end
end
def user_params
params.require(:user).permit(permitted_user_attributes |
[:use_billing,
role_ids: [],
ship_address_attributes: permitted_address_attributes,
bill_address_attributes: permitted_address_attributes])
end
仔细阅读上述代码,会让人感到心惊肉跳。这段代码非常缺乏连贯性,我们的思维从领域逻辑一跃而下,经过框架API到交付机制的细节,然后辗转安全细节,再到业务逻辑,最后返回。看似是一段HTTP层的代码,里面却夹杂着许多业务逻辑。如果是在一个干净的分层架构中,我们肯定会分离这些技术细节,避免将它们混合在一起,同时将业务逻辑统一放在一个地方。在这样的架构中,框架的作用并不重要,领域(或层)的意义就在于独立、没有任何依赖关系。这样的领域代码不会依赖于反序列化 JSON、HTTP 标头、数据库事务、连接池等任何技术细节。这样的领域只关心领域语言,比如它只会调用抽象方法posts_repository.create(post)。这样的系统拥有良好的可维护性,因为所有代码的作用都很明确。这样的系统是隔离的,而且是一个整体。如果你想修改Post的存储(比如你放弃MongoDB,转而采用直接在磁盘中保存Markdown文件),则只需修改PostsRepository。任何与业务逻辑相关的代码都不需要动。将这些实现细节放入单独的一层,那么软件就会更加易于维护,因为代码变更都是单独的。有了这样的架构,即便使用了框架,也会被抛在一边,而且每次只需更换一小块的难度会大大降低。以解耦的方式采用框架,不仅能享受框架带来的好处,而且还可以避免损害可维护性
许多人可能会说,不使用框架则意味着我们需要动手编写所有代码。这种非黑即白的看法有点过于极端。我们可以很好地利用库和框架,同时也要编写好代码。我们应该依靠(安全)专家来编写关系到安全的代码。如果可以避免,我们又何须学习如何编写加密算法或处理密码的代码。我们应该使用库来处理这些细节。但是,我们应该明确指定一个单独的地方。负责将HTTP路径映射为方法调用的代码就应该放在HTTP层,不应该牵扯任何业务逻辑。隔离度越高,可维护性就越好。代码令牌认证等处理不应该由我们编写,而是应该统一放入一个单独的、有界限的区域。最好将其封装起来,并转换成领域语言,如authentication.is_known_as_admin(request.token)。发送消息的方法应该简单地定义为messenger.deliver(recipent, body)。该方法的背后是一个完整的消息传递框架,不仅提供指数退避重试、缓冲、智能路由等功能,而且可以推送通知和发送电子邮件。保存费用的方法叫做expenses_repository.add(expense),其背后可能使用了世界上最复杂的分布式数据库框架,或者使用了一个漂亮的框架将费用推送到某个在线会计工具中。关键不是永远不要使用框架,而是要隔离它们,并统一从一个地方调用。将框架的影响范围降到最低,这是我们的责任。然而,大多数框架预先定制了很多技术细节,并且都混合在一起。因此,我们很难将它们分开。这样的框架已经失去了意义,很快就会变成库。为什么没有这样的框架?
首先,我们的基本思路是不依赖于框架,但构建框架却不使用框架,这与框架本身的目标背道而驰。其次,可维护性良好的软件需要随着时间的推移而不断发展,以适应不断变化的需求。从HTTP迁移到事件总线时,显然你不再需要HTTP框架。当从基于 Web 的服务转而使用原生移动应用的服务时,你所需要的也不再是HTML/CSS/asset,而是序列化和处理 JSON 请求的方法。可维护性要求软件不断发展。HTTP框架提供HTTP服务,但是当需求发生变化,且你不再需要HTTP服务时,却没办法删掉这些框架。一些 MVC 框架提供使用关系数据库的 ORM,但如果ORM框架过时,你也没办法摆脱它们。第三,有些实现并不需要框架。例如,CQRS之类的架构实际上就是一个简单的if语句:if(is_command) { command(params) } else { query(params) },写这种代码根本不需要框架。最后,维护工作的难易程度与使用特定的工具或框架无关。正如Symfony指出的那样:最佳实践可以保证应用程序的稳定性、可维护性和可升级性。我们创建了一个高质量的技术交流群,与优秀的人在一起,自己也会优秀起来,赶紧点击加群,享受一起成长的快乐。另外,如果你最近想跳槽的话,年前我花了2周时间收集了一波大厂面经,节后准备跳槽的可以点击这里领取!推荐阅读
··································你好,我是程序猿DD,10年开发老司机、阿里云MVP、腾讯云TVP、出过书创过业、国企4年互联网6年。从普通开发到架构师、再到合伙人。一路过来,给我最深的感受就是一定要不断学习并关注前沿。只要你能坚持下来,多思考、少抱怨、勤动手,就很容易实现弯道超车!所以,不要问我现在干什么是否来得及。如果你看好一个事情,一定是坚持了才能看到希望,而不是看到希望才去坚持。相信我,只要坚持下来,你一定比现在更好!如果你还没什么方向,可以先关注我,这里会经常分享一些前沿资讯,帮你积累弯道超车的资本。